feat(pyramid): add fast multi-layer root routing - #2717
Conversation
|
/label status/waiting-for-review |
Merge Protections🟢 All 3 merge protections satisfied — ready to merge. Show 3 satisfied protections🟢 Require kind label
🟢 Require version label
🟢 Require linked issue for feature/bug PRs
|
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in multi-layer routing overlay for Pyramid hierarchy roots (HGraph-style sparse route layers + existing complete bottom graph) and aligns Pyramid’s search-time reorder behavior with the common factor parameter, including additional stats and serialization support.
Changes:
- Add per-hierarchy
root_graph_type(single_layerdefault,multi_layeropt-in) and implement root route-graph build/search + persistence. - Apply
factorto cap reorder candidate count (while preserving requested final TopK) and exposereorder_candidate_countin query statistics. - Enforce
hops_limitinParallelSearcherand extend Pyramid’s hop limiting behavior to non-root graph nodes (while excluding route graphs).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_pyramid.cpp | Adds functional coverage for multi-layer root behavior and streaming/binary-set serialization. |
| src/query_context.h | Adds reorder_candidate_count to search statistics JSON and storage. |
| src/impl/searcher/parallel_searcher.cpp | Enforces hops_limit during parallel graph traversal. |
| src/impl/searcher/parallel_searcher_test.cpp | Adds unit test validating hops_limit behavior. |
| src/impl/reorder/flatten_reorder.cpp | Records reorder candidate counts for flatten reorder paths. |
| src/impl/reorder/bucket_reorder.cpp | Records reorder candidate counts for bucket reorder. |
| src/constants.cpp | Introduces Pyramid root_graph_type constants. |
| src/analyzer/pyramid_analyzer.cpp | Updates analyzer search call to pass entry point explicitly. |
| src/algorithm/pyramid/pyramid.h | Adds multi-layer root routing structures, APIs, and memory reporting declarations. |
| src/algorithm/pyramid/pyramid.cpp | Implements root route graph planning/build/search, factor-based reorder limiting, stats, memory reporting, and serialization hooks. |
| src/algorithm/pyramid/pyramid_zparameters.h | Adds root_graph_type to parameter structs. |
| src/algorithm/pyramid/pyramid_zparameters.cpp | Validates root_graph_type and explicit factor; wires config mapping/compat checks. |
| src/algorithm/pyramid/pyramid_zparameters_test.cpp | Adds tests for root_graph_type validation and explicit factor validation. |
| src/algorithm/pyramid/pyramid_test.cpp | Adds unit tests for routing, serialization survival, factor semantics, duplicates, and hop limiting behavior. |
| src/algorithm/index_search_parameter.h | Tracks whether factor was explicitly provided (has_topk_factor). |
| include/vsag/constants.h | Exposes new Pyramid constants in the public header. |
| docs/docs/zh/src/indexes/pyramid.md | Documents root_graph_type, factor, updated hops_limit semantics, and new stats fields (ZH). |
| docs/docs/en/src/indexes/pyramid.md | Documents root_graph_type, factor, updated hops_limit semantics, and new stats fields (EN). |
Suppressed comments (3)
src/algorithm/pyramid/pyramid.cpp:428
- IndexNode::Search reads entry_point_ outside the node mutex and passes it into search_func. Since entry_point_ is written under node->mutex_ during insert/promote, this introduces a data race and can pass a torn/stale value to graph search. Capture entry_point_ while holding the shared_lock and pass the snapshot to search_func.
bool has_index = false;
{
std::shared_lock lock(mutex_);
has_index = status_ != IndexNode::Status::NO_INDEX;
}
src/algorithm/pyramid/pyramid.cpp:1396
- rebuild_root_routes_by_nsw() calls plan_root_route_ids(), which assigns hierarchy.root->entry_point_. That write currently happens while only holding a shared_lock on root->mutex_, which violates the lock contract and can race with concurrent readers/writers. Use an exclusive lock here (or stop mutating entry_point_ inside route planning).
std::unique_lock route_lock(h_ptr->root_routing_mutex);
if (not h_ptr->root_routes_initialized) {
std::shared_lock root_lock(h_ptr->root->mutex_);
rebuild_root_routes_by_nsw(*h_ptr);
h_ptr->root_routes_initialized = true;
src/algorithm/pyramid/pyramid.cpp:239
- add_to_root_routes updates hierarchy.root->entry_point_ without holding IndexNode::mutex_. Since entry_point_ is guarded by node->mutex_ elsewhere (e.g., add_one_point), this write can race with concurrent reads/writes. Update entry_point_ under an exclusive lock on hierarchy.root->mutex_ (or make entry_point_ atomic / store a separate routing entry point in Hierarchy).
for (int route_level = current_top + 1; route_level <= level; ++route_level) {
auto graph = make_root_route_graph(hierarchy);
graph->InsertNeighborsById(inner_id, Vector<InnerIdType>(allocator_));
hierarchy.root_route_graphs.push_back(std::move(graph));
hierarchy.root->entry_point_ = inner_id;
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
src/algorithm/pyramid/pyramid.cpp:770
search_param.topkis used as thetopkargument toreorder_->Reorder(...), but whenfactoris set this value represents the reorder candidate limit, not the requested final TopK. Passing the larger value increases reorder heap work unnecessarily; the final result size is already enforced byfinal_topklater in this function.
search_result = this->reorder_->Reorder(search_result,
query->GetFloat32Vectors(),
search_param.topk,
ctx,
docs/docs/en/src/indexes/pyramid.md:160
- The docs describe
factoras a general search parameter, but the implementation only applies it inKnnSearch(RangeSearch ignores it). Clarify thatfactoraffects reorder candidate limiting for KnnSearch so users don’t assume it impacts RangeSearch.
| `factor` | float | unset | Reorder candidate multiplier. When set to `<= 1`, reorder up to `max(ef_search, topk)` candidates; when greater than `1`, reorder up to `min(max(ef_search, topk), floor(topk * factor))`. It must be finite and positive. It has no effect when reorder is disabled. |
docs/docs/zh/src/indexes/pyramid.md:154
- 文档将
factor描述为通用检索参数,但实现仅在KnnSearch中应用(RangeSearch会忽略)。建议注明factor仅影响 KnnSearch 的重排候选数,避免用户误以为范围检索也会生效。
| `factor` | float | 未设置 | 重排候选倍率。值 `<= 1` 时最多重排 `max(ef_search, topk)` 个候选;值大于 `1` 时最多重排 `min(max(ef_search, topk), floor(topk * factor))` 个候选。必须为有限正数;关闭重排时不生效。 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/algorithm/pyramid/pyramid_zparameters.cpp:226
root_graph_typeis currently allowed whenno_build_levelscontains level 0 as long as the value issingle_layer. The linked issue/PR description calls out rejecting configs that specify a root graph type while level 0 is disabled; allowing this makes misconfigurations harder to catch (the root graph type is meaningless when the root graph is not built).
if (json.Contains(PYRAMID_ROOT_GRAPH_TYPE)) {
CHECK_ARGUMENT(json[PYRAMID_ROOT_GRAPH_TYPE].IsString(),
"root_graph_type must be a string");
this->root_graph_type = json[PYRAMID_ROOT_GRAPH_TYPE].GetString();
}
validate_root_graph_config(this->root_graph_type, this->no_build_levels, "Pyramid");
src/analyzer/pyramid_analyzer.cpp:1082
node->entry_point_is read without holdingIndexNode::mutex_when callingPyramid::search_node(). Other call paths snapshot the entry point under the node lock to avoid races with concurrent add/promote/route updates, so this analyzer path can observe a torn/stale value.
node->entry_point_);
src/algorithm/pyramid/pyramid.cpp:1241
- Binary-set
Pyramid::Deserialize()does not validate the storedINDEX_PARAMagainstcreate_param_ptr_before conditionally deserializingroot_route_graphs. Because route-graph payload presence depends on the configuredroot_graph_type, loading with a mismatched config can misalign the stream and produce confusing failures (or attempt to deserialize graphs from non-graph bytes). Streaming deserialization already performs aCheckCompatibility()guard; the non-streaming path should do the same before reading hierarchy payloads.
deserialize_root_routes(buffer_reader, *h_iter->second);
src/algorithm/pyramid/pyramid_zparameters.cpp:131
- Per-hierarchy parsing has the same gap: an explicitly provided
root_graph_typeis accepted even whenno_build_levelsdisables level 0, as long as the value issingle_layer. That contradicts the stated requirement to reject specifying a root graph type when level 0 is not built.
This issue also appears on line 221 of the same file.
if (json.Contains(PYRAMID_ROOT_GRAPH_TYPE)) {
CHECK_ARGUMENT(json[PYRAMID_ROOT_GRAPH_TYPE].IsString(),
fmt::format("hierarchy {} root_graph_type must be a string", name));
root_graph_type = json[PYRAMID_ROOT_GRAPH_TYPE].GetString();
}
6663d5f to
34987f4
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/algorithm/pyramid/pyramid.cpp:779
- In search_impl(), the reorder stage is invoked with
topk=search_param.topk, butsearch_param.topkis now overloaded to mean the reorder candidate limit whenfactoris provided. This makes reorder maintain a larger heap than necessary (oftenef), increasing CPU/memory cost without affecting the final TopK (which is later trimmed tofinal_topk). Passfinal_topkto Reorder() and keep the candidate cap viareorder_candidate_limit.
search_result = this->reorder_->Reorder(search_result,
query->GetFloat32Vectors(),
search_param.topk,
ctx,
nullptr,
rabitq_lower_bound_candidates);
src/algorithm/index_search_parameter.h:44
- IndexSearchParameter::FromJson sets has_topk_factor=true when
factoris present, but it never resetshas_topk_factor(or topk_factor) when parsing JSON that omits the field. If the same IndexSearchParameter instance is reused across requests, a previously provided factor will incorrectly persist into later searches.
if (json.Contains(SEARCH_PARAM_FACTOR)) {
topk_factor = json[SEARCH_PARAM_FACTOR].GetFloat();
has_topk_factor = true;
}
Signed-off-by: jc543239 <jc543239@antgroup.com> Assisted-by: Codex:gpt-5
Signed-off-by: jc543239 <jc543239@antgroup.com> Assisted-by: Codex:gpt-5
Signed-off-by: jc543239 <jc543239@antgroup.com> Assisted-by: Codex:gpt-5
Signed-off-by: jc543239 <jc543239@antgroup.com> Assisted-by: Codex:gpt-5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/test_pyramid.cpp:112
- GeneratePyramidBuildParametersString() builds invalid JSON when param.root_graph_type is unset: the template line ""index_min_size": 28{}" has no trailing comma, and root_graph_parameter becomes an empty string in the common case, so the generated JSON is missing the comma before "support_duplicate" and will fail to parse.
"precise_quantization_type": "{}",
"use_reorder": {},
"index_min_size": 28{},
"support_duplicate": {}
}}
}}
)";
const auto root_graph_parameter =
param.root_graph_type.has_value()
? fmt::format(",\n \"root_graph_type\": \"{}\"", *param.root_graph_type)
: "";
src/algorithm/pyramid/pyramid.cpp:1724
- encode_add_batch() can run base_codes_/precise_codes_/raw_vector_ InsertVector() calls concurrently via run_parallel_blocks(), but FlattenDataCell is explicitly documented as thread-unsafe (src/datacell/flatten_datacell.h:35-37) and InsertVector() only locks around total_count_ while encoding + layout_->Write() run unlocked. This can introduce data races/corruption for common Pyramid code stores. Please gate parallel encoding to datacells/IO backends that explicitly support concurrent disjoint-ID writes (e.g., in-memory FP32/RaBitQ/SQ8 as previously), or serialize these InsertVector() calls.
"precise codes capacity is smaller than the encoded id range");
}
if (raw_vector_ != nullptr) {
CHECK_ARGUMENT(raw_vector_->max_capacity_ >= required_capacity,
"raw vector capacity is smaller than the encoded id range");
}
const auto* data_vectors = base->GetFloat32Vectors();
const auto encode_range = [this, data_vectors, &batch](uint64_t begin, uint64_t end) {
for (uint64_t offset = begin; offset < end; ++offset) {
const auto* vector = data_vectors + dim_ * batch.input_indices[offset];
const auto inner_id = static_cast<InnerIdType>(batch.first_inner_id + offset);
base_codes_->InsertVector(vector, inner_id);
if (has_precise_reorder()) {
precise_codes_->InsertVector(vector, inner_id);
}
if (raw_vector_ != nullptr) {
raw_vector_->InsertVector(vector, inner_id);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/algorithm/pyramid/pyramid_zparameters.cpp:239
- Issue #2712 / PR description state that configurations which specify root_graph_type should be rejected when no_build_levels disables level 0. Current parsing accepts an explicit "root_graph_type": "single_layer" with no_build_levels:[0] (and the unit test asserts this), because validation only restricts multi_layer. If Fixes:#2712 is intended, add a CHECK_ARGUMENT here (or equivalent) to reject any explicit root_graph_type when the effective no_build_levels contains 0, and update the parameter tests/docs accordingly; otherwise the issue closure criteria aren’t met.
if (json.Contains(PYRAMID_ROOT_GRAPH_TYPE)) {
CHECK_ARGUMENT(json[PYRAMID_ROOT_GRAPH_TYPE].IsString(),
"root_graph_type must be a string");
this->root_graph_type = json[PYRAMID_ROOT_GRAPH_TYPE].GetString();
}
LHT129
left a comment
There was a problem hiding this comment.
Code Review for PR #2717
[critical] IndexNode::Search children_ data race regression
File: src/algorithm/pyramid/pyramid.cpp, IndexNode::Search() (line ~746 in new file)
Commit bc31f9cf added a snapshot of child pointers under shared_lock before iterating, which addressed the data race between concurrent Add (creating new path nodes via AddChild) and Search (traversing children_). Commit 81535481 ("refactor(pyramid): simplify routed build lifecycle") removed the snapshot and reverted to direct iteration of children_ without the lock.
The children_ map (UnorderedMap<std::string, std::shared_ptr<IndexNode>>) is mutated by GetChild/AddChild under node.mutex_. Concurrent Search iterating the map without synchronization is a data race and undefined behavior.
Please restore the snapshot pattern or hold a shared_lock for the duration of the iteration.
[suggestion] build_with_cache does not seed route graphs from cache
File: src/algorithm/pyramid/pyramid.cpp, build_with_cache() (~line 2755)
When build_with_cache processes a node with has_routing() == true, cache-hit entries seed only the bottom graph (gnode->graph_->InsertNeighborsById). The route graphs are built from scratch during the subsequent refinement phase via add_routed_point. This means the route graph structure is not preserved across rebuilds, which could lead to different search performance characteristics after a cache-based rebuild compared to a fresh build.
Consider whether route graph neighbor lists should also be cached and restored, or document this as an intentional design trade-off.
[note] Overall assessment
The PR is well-structured with thorough test coverage (9 new test cases covering build, serialization, concurrent Add/Search, factor control, duplicate handling, hops_limit, and parameter validation). The double-checked locking pattern in add_routed_point, the seed-first parallel insertion strategy, and the dedicated random_generator_mutex_ all demonstrate careful attention to concurrency correctness. The serialization compatibility checks and parameter validation are comprehensive.
One regression noted above (children_ snapshot removal) should be addressed before merge.
|
Thanks for the detailed review. I checked both review-level points against the current head and the PR merge base ( For The cache behavior is intentional. No code change is needed for these two points in this PR. |
LHT129
left a comment
There was a problem hiding this comment.
Code Review for PR #2717 (commit 14e016c)
Summary
This PR adds multi-layer root routing to the Pyramid index, enabling sparse route graphs above the dense Flat bottom graph. The architecture is well-structured with clear separation between routing and bottom graph operations. The double-checked locking pattern for routing graph growth is correct, and the seed-first insertion strategy ensures the routing structure is established before parallel workers run.
Suggestions
[suggestion] add_routed_point double-checked locking: TOCTOU window between unlock and relock
src/algorithm/pyramid/pyramid.cpp lines 411-412: After releasing the write lock and before re-acquiring the shared lock, the routing structure could theoretically be modified by another thread. While benign in practice (routing graphs only grow via push_back, and insert_locked() recomputes current_top), the pattern is fragile. Consider adding a comment explaining the safety invariant.
[suggestion] entry_point_ read inside insert_locked under shared_lock
src/algorithm/pyramid/pyramid.cpp line 317: node.entry_point_ is read inside insert_locked(), which may execute under a shared_lock. This is safe because the seed insertion (which writes entry_point_) always runs under unique_lock before any worker runs under shared_lock. However, this depends on the invariant that the seed has the maximum sampled level. If the seed selection logic changes in the future, this could become a data race. Consider adding a comment documenting this invariant.
[suggestion] build_with_cache double-seed pattern for hit_ids vs missed_ids
src/algorithm/pyramid/pyramid.cpp lines 2805-2808: refine_nodes is called separately for node_missed_ids and node_hit_ids, each with its own run_parallel_insertions call. This means two seeds are inserted (one for missed, one for hit). If the hit seed has a higher level than the miss seed, the routing graphs need to grow during the hit phase, which is handled correctly by the double-checked locking. However, the interaction between the two phases is subtle. Consider adding a brief comment explaining that the second seed may trigger routing graph growth.
[note] hops_limit=10000 hardcoded in add_routed_point and add_bottom_graph_point
src/algorithm/pyramid/pyramid.cpp lines 338 and 2045: The construction-time graph search uses a hardcoded hops_limit=10000. This is consistent with the existing add_bottom_graph_point behavior, but differs from the search path where hops_limit is configurable. If very large graphs are built, this limit could be reached. Consider making this configurable or documenting the rationale.
What was verified
- Multi-layer routing architecture:
add_routed_point→ route graph traversal → bottom graph insertion → route graph insertion - Double-checked locking for routing graph growth is correct
- Seed-first insertion ensures routing structure exists before parallel workers
resolve_entry_pointcorrectly traverses route graphs top-down during search- Serialization/deserialization of routing graphs with
MAX_ROOT_ROUTE_GRAPH_COUNT=1024validation build_with_cachecorrectly handles routed root nodes- FLAT-to-GRAPH promotion does not incorrectly enable routing for non-root nodes
entry_point_updates are properly protected bynode.mutex_
Signed-off-by: jc543239 <jc543239@antgroup.com> Assisted-by: Codex:gpt-5
| task(seed_index, sampled_levels[seed_index]); | ||
| } | ||
|
|
||
| Vector<std::future<void>> futures(allocator_); |
There was a problem hiding this comment.
[suggestion] run_parallel_insertions now submits one future per vector element instead of one per worker. For large datasets (e.g., 1M vectors), this creates 1M std::future objects and 1M GeneralEnqueue calls, each with internal synchronization overhead. The old run_parallel_blocks design used build_thread_count_ workers that dynamically claimed 64-vector blocks, which kept future count and enqueue overhead bounded.
Consider reverting to a block-based approach or at minimum batching elements per future (e.g., submit build_thread_count_ futures each processing a contiguous range). The encode_add_batch change in this same commit already uses fixed partitioning — a similar approach would work here.
| return results; | ||
| } | ||
|
|
||
| DistHeapPtr |
There was a problem hiding this comment.
[note] merge_cached_graph_candidates replaces connect_cached_graph_point and now only merges candidates without performing edge selection (select_edges_by_heuristic) or installing edges. The caller is responsible for calling mutually_connect_new_element afterward.
The old code used LockGuard (exclusive) on points_mutex_ for the current row while reading neighbors, selecting edges, and writing forward edges. The new code uses SharedLock for reading cached neighbors only. This is correct since the new function is read-only, but the behavioral change is worth noting: the old code ensured that no concurrent writer could modify the cached row between read and write, while the new code separates read and write into two calls with an unlocked window between them. If another thread modifies the same row between merge_cached_graph_candidates and the subsequent mutually_connect_new_element, the merged candidates may be stale. Verify that callers (e.g., add_routed_point, add_bottom_graph_point) are safe with this separation.
| if (batch.storage_preallocated and stores_support_parallel_encode and thread_pool_ != nullptr && | ||
| build_thread_count_ > 1 and batch.input_indices.size() > 1) { | ||
| const uint64_t count = batch.input_indices.size(); | ||
| const uint64_t worker_count = std::min<uint64_t>(build_thread_count_, count); |
There was a problem hiding this comment.
[note] The old run_parallel_blocks had careful exception handling: it caught exceptions from both GeneralEnqueue and worker futures, propagated cancellation via an atomic flag, and rethrew the first exception. The new inline parallel encoding code in encode_add_batch simply calls future.get() in a loop — if an early future throws, the remaining futures are never waited on, potentially leaving the thread pool in an inconsistent state with dangling tasks.
Consider collecting all exceptions (like the old code did) or at minimum ensuring all futures are waited on before rethrowing. For example:
std::exception_ptr first_exception = nullptr;
for (auto& future : futures) {
try {
future.get();
} catch (...) {
if (first_exception == nullptr) {
first_exception = std::current_exception();
}
}
}
if (first_exception != nullptr) {
std::rethrow_exception(first_exception);
}| } | ||
| } | ||
| for (auto& future : futures) { | ||
| future.get(); |
There was a problem hiding this comment.
[note] Same exception-handling concern as in encode_add_batch: run_parallel_insertions calls future.get() in a loop without collecting exceptions. If an early future throws, remaining futures are never waited on. The old run_parallel_blocks had proper exception collection and cancellation propagation. Consider applying the same pattern here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
tests/test_pyramid.cpp:112
- The JSON template in GeneratePyramidBuildParametersString is missing a comma after the index_min_size field. As written, both the default case (no root_graph_type) and the opt-in case produce invalid JSON (no comma between "index_min_size" / optional root_graph_type and "support_duplicate"). Add an unconditional comma separator (and adjust the optional fragment so it doesn’t introduce a double comma).
"fast_encode_rabitq": {},
"fast_encode_rabitq_rounds": 6,
"precise_quantization_type": "{}",
"use_reorder": {},
"index_min_size": 28{},
"support_duplicate": {}
}}
}}
)";
const auto root_graph_parameter =
param.root_graph_type.has_value()
? fmt::format(",\n \"root_graph_type\": \"{}\"", *param.root_graph_type)
: "";
src/algorithm/pyramid/pyramid_zparameters.cpp:61
- validate_root_graph_config only rejects level-0-disabled configurations for root_graph_type="multi_layer". Per the linked issue/PR description, any explicit root_graph_type should be rejected when no_build_levels disables level 0 (otherwise the setting is moot/ambiguous). Consider extending validation to cover the explicit "single_layer" case as well (you may need to thread an "explicitly_set" flag into validation).
validate_root_graph_config(const std::string& root_graph_type,
const std::vector<int32_t>& no_build_levels,
int64_t max_degree,
const std::string& context) {
validate_root_graph_type(root_graph_type, context);
CHECK_ARGUMENT(
root_graph_type != PYRAMID_ROOT_GRAPH_TYPE_MULTI_LAYER ||
std::find(no_build_levels.begin(), no_build_levels.end(), 0) == no_build_levels.end(),
fmt::format("{} multi-layer root graph requires level 0 to be built", context));
CHECK_ARGUMENT(
root_graph_type != PYRAMID_ROOT_GRAPH_TYPE_MULTI_LAYER || max_degree > 1,
fmt::format("{} multi-layer root graph requires max_degree greater than 1", context));
}
| unbuilt_root = vsag::JsonType::Parse(R"({ | ||
| "base_quantization_type": "fp32", | ||
| "root_graph_type": "single_layer", | ||
| "no_build_levels": [0] | ||
| })"); | ||
| REQUIRE_NOTHROW(vsag::Pyramid::CheckAndMappingExternalParam(unbuilt_root, common_param)); | ||
|
|
||
| auto explicit_hierarchy_root = vsag::JsonType::Parse(R"({ | ||
| "base_quantization_type": "fp32", | ||
| "hierarchies": [ | ||
| { | ||
| "name": "site", | ||
| "root_graph_type": "single_layer", | ||
| "no_build_levels": [0] | ||
| } | ||
| ] | ||
| })"); | ||
| REQUIRE_NOTHROW( | ||
| vsag::Pyramid::CheckAndMappingExternalParam(explicit_hierarchy_root, common_param)); |
Change Type
Linked Issue
What Changed
root_graph_typewith backward-compatiblesingle_layerand opt-inmulti_layer.factorwithout losing the caller's final TopK, applyhops_limitto leaf searches, and expose reorder candidate statistics.IndexNode: callers invoke one node-search path and do not inspect whether a node has routing layers.The final diff contains no HGraph or DataCell implementation changes.
Construction Design
For
multi_layer, a Pyramid root owns a pre-sized dense Flat bottom graph containing every root vector and sparse upper route graphs sampled from the same level assignment. Each vector traverses the existing route hierarchy, connects to the bottom graph, and then connects to its eligible route levels.The public build flow remains linear: prepare the batch, encode it, populate hierarchy trees, and insert the batch. Route sampling, seed insertion, route creation, and parallel block scheduling are hidden inside the node insertion path. The highest-level seed is inserted synchronously before workers start, so workers only mutate graph contents and never concurrently grow the route-graph container.
Cold Build resizes every configured code store before encoding. Parallel encoding retains main's established in-memory FP32/RaBitQ/SQ8 capability boundary and additionally requires the requested ID range to have been physically preallocated for every store; otherwise the same encoding path runs sequentially. No DataCell capability API or broader concurrency contract is added.
multi_layer + odescentis rejected during parameter validation. Single-layer ODescent remains unchanged. When independent precise storage exists, construction uses precise codes; otherwise it uses base codes. Search uses base codes for graph traversal and the configured reorder source for final reorder.Test Evidence
Only tests related to the modified Pyramid paths were run locally, per review scope. PR CI and TSAN remain required.
SIFT1M Final-Head Comparison
Final head:
14e016c4(feature implementation81535481, latest upstream-main merge, and focused review follow-ups).Configuration: SIFT1M, 1,000,000 x 128-d L2 vectors, RaBitQ 3-bit base + SQ8 precise reorder, NSW,
max_degree=64,ef_construction=400,alpha=1.0, 96 build threads pinned to logical CPUs 0-95, TopK 10. HGraph uses Flat graph storage; Pyramid usesroot_graph_type: "multi_layer"andno_build_levels: [1].Both benchmark executables use the same clean dynamic VSAG linkage and do not embed a second BLAS implementation. Build figures are cold final-head runs. Search uses one pinned thread; the table reports the median of three runs at the same
ef_search=100.At effectively identical recall and the same search parameter, Pyramid builds 1.90x faster and reduces wall time by 47.26%. Its measured single-thread QPS is 3.32% higher in this run. HGraph is the unchanged upstream implementation.
Compatibility Impact
single_layerdefault.multi_layerrequires a built level 0,max_degree > 1, and NSW construction;multi_layer + odescentis rejected.factorretain their existing behavior; explicit invalid factors are rejected.Performance and Concurrency Impact
Documentation Impact
docs/docs/en/src/indexes/pyramid.mddocs/docs/zh/src/indexes/pyramid.mdRisk and Rollback
multi_layer.Checklist